/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package androidx.graphics.shapes

import kotlin.jvm.JvmStatic

/**
 * The [FeatureSerializer] is used to both serialize and parse [Feature] objects. This is beneficial
 * when you want to re-use [RoundedPolygon] objects created by [SvgPathParser], as parsing
 * serialized [Feature] objects is more performant than using the svg path import.
 *
 * **Example:**
 *
 * ```
 * // Do the following three *once*
 * val triangleSVGPath: String = "M0,0 0.5,1 1,0Z"
 * val triangleFeatures: List<Feature> = SvgPathParser.parseFeatures(triangleSVGPath)
 * val serializedTriangle: String = FeatureSerializer.serialize(triangleFeatures)
 *
 * // Parse the serialized triangle features in your production code.
 * // You can adjust them (e.g. the type) however you want before parsing.
 * val features: List<Feature> = FeatureSerializer.parse(serializedTriangle)
 * val triangle: RoundedPolygon = RoundedPolygon(features, centerX = 0.5f, centerY = 0.5f)
 * Morph(triangle, ...)
 * ```
 */
class FeatureSerializer private constructor() {
    companion object {

        /**
         * Serializes a list of [Feature] objects into a string representation, adhering to version
         * 1 of the feature serialization format.
         *
         * **Format:**
         * 1. **Version Identifier:** A 'V' followed by the version number (e.g., 'V1').
         * 2. **Feature Serialization:** Each [Feature] is serialized individually and concatenated.
         *     - **Feature Tag:** A single-character tag identifies the feature type:
         *         - 'n': Edge
         *         - 'x': Convex Corner
         *         - 'o': Concave Corner
         *     - **Cubic Serialization:** Each [Cubic] within a feature is serialized as a sequence
         *       of comma-separated x,y coordinates. Subsequent cubics within a feature omit their
         *       first anchor points, as they're identical to their predecessors' last anchor
         *       points.
         *
         * **Example:** Given two features:
         * - An edge with one [Cubic]: { (0, 0), (1, 1), (2, 2), (3, 3) }
         * - A convex corner with two [Cubic] objects: { (0, 0), (1, 1), (2, 2), (3, 3) }, { (3, 3),
         *   (4, 4), (5, 5), (6, 6) }
         *
         * The serialized string would be:
         * ```
         * V1 n 0,0, 1,1, 2,2, 3,3  x 0,0, 1,1, 2,2, 3,3, 4,4, 5,5, 6,6
         * ```
         *
         * @param features The list of [Feature] objects to serialize.
         * @return The serialized string representation of the [features]
         */
        @JvmStatic
        fun serialize(features: List<Feature>): String {
            return buildString {
                append("V1")
                for (feature in features) {
                    append(serializeFeature(feature))
                }
            }
        }

        /**
         * Parses a serialized string representation of [Feature] into their object representations,
         * adhering to version 1 of the feature serialization format.
         *
         * The serialized string must adhere to the format generated by
         * [FeatureSerializer.serialize]. This format consists of:
         * 1. **Version Identifier:** A 'V' followed by the version number (e.g., 'V1').
         * 2. **Feature Serialization:** Each [Feature] is serialized individually and concatenated.
         *     - **Feature Tag:** A single-character tag identifies the feature type:
         *         - 'n': Edge
         *         - 'x': Convex Corner
         *         - 'o': Concave Corner
         *         - Any other tags unknown to version 1 will default to an edge interpretation.
         *     - **Cubic Serialization:** Each [Cubic] within a feature is serialized as a sequence
         *       of comma-separated x,y coordinates. Subsequent cubics within a feature omit their
         *       first anchor points, as they're identical to their predecessors' last anchor
         *       points.
         *
         * **Note:** The current version (1) of the serialization format is stable. However, future
         * versions may introduce incompatible changes with version 1. The default behavior for
         * unknown or missing versions will be version 1 parsing. If you have a later version
         * string, update the library to the latest version.
         *
         * @param serializedFeatures The serialized [Feature] objects in a concatenated String.
         * @return A list of parsed [Feature] objects, corresponding to the serialized input.
         * @throws IllegalArgumentException - if a serialized string lacks sufficient points to
         *   create [Cubic] objects with
         * @throws IllegalArgumentException - if no feature tags could be found
         * @throws NumberFormatException - if the [Cubic]s' coordinates aren't valid representations
         *   of numbers.
         */
        @JvmStatic
        fun parse(serializedFeatures: String): List<Feature> {
            val version = Regex("^\\s*V(\\d+)").find(serializedFeatures)
            var tagsSearchStart = 0

            if (version == null || version.groupValues.size < 2) {
                debugLog(LOG_TAG) {
                    "Could not find any version attached to the start of the input. Will default to V1 parsing."
                }
            } else {
                if (version.groupValues[1] != "1") {
                    debugLog(LOG_TAG) {
                        "Found an unsupported version number ${version.groupValues[1]}. Will default to version 1 parsing. Please update your library version to the latest version to fix this issue. "
                    }
                }
                tagsSearchStart = version.value.length
            }

            val tags = Regex("[a-zA-Z]").find(serializedFeatures, tagsSearchStart)
            require(tags != null) {
                "Could not find any feature tags. Please mark all cubic bezier curve points belonging to a feature with one of {${FEATURE_TAG_ARRAY.joinToString(", ")}} for V1, e.g. 'n1,1,2,2,3,3,4,4' for an edge (n) with anchor 0 (1,1), control 0 (2,2), control 1 (3,3) and anchor 1 (4,4)."
            }

            var currentMatch = tags
            var featureStart: Int
            var featureEnd: Int
            return buildList {
                while (currentMatch != null) {
                    featureStart = currentMatch!!.range.first
                    currentMatch = currentMatch!!.next()
                    featureEnd =
                        if (currentMatch != null) currentMatch!!.range.first
                        else serializedFeatures.length
                    add(parseFeature(serializedFeatures, featureStart, featureEnd))
                }
            }
        }

        private fun serializeFeature(feature: Feature): String =
            when (feature) {
                is Feature.Edge -> {
                    EDGE_CHAR + serializeCubics(feature.cubics)
                }
                is Feature.Corner -> {
                    val convexFlag = if (feature.convex) CONVEX_CORNER_CHAR else CONCAVE_CORNER_CHAR
                    convexFlag + serializeCubics(feature.cubics)
                }
                else -> {
                    debugLog(LOG_TAG) {
                        "Serializing a Feature unknown to V1 (knows Edge and Corner). Will default to parse as an edge. Please update the library to the latest version to fix this issue."
                    }
                    EDGE_CHAR + serializeCubics(feature.cubics)
                }
            }

        private fun serializeCubics(cubics: List<Cubic>): String {
            // since cubics in a polygon are continuous, we don't need to include the end
            // coordinates as they are the same as the start coordinates of their successors.
            // this is similar to svg path commands.
            val separatorString = SEPARATOR.toString()
            return buildString {
                for (cubic in cubics) {
                    append(
                        cubic.points.joinToString(
                            separator = separatorString,
                            limit = 6,
                            truncated = "",
                        ) {
                            it.toString().removeTrailingZeroes()
                        }
                    )
                }
                val lastX = cubics.last().anchor1X.toString().removeTrailingZeroes()
                val lastY = cubics.last().anchor1Y.toString().removeTrailingZeroes()
                append("${lastX}$SEPARATOR${lastY}")
            }
        }

        private fun parseFeature(serialized: String, startIndex: Int, endIndex: Int): Feature {
            return when (serialized[startIndex]) {
                EDGE_CHAR -> Feature.Edge(parseCubics(serialized, startIndex + 1, endIndex))
                CONVEX_CORNER_CHAR ->
                    Feature.Corner(parseCubics(serialized, startIndex + 1, endIndex), true)
                CONCAVE_CORNER_CHAR ->
                    Feature.Corner(parseCubics(serialized, startIndex + 1, endIndex), false)
                else -> {
                    debugLog(LOG_TAG) {
                        "Found an unknown Feature tag for V1 parsing. Given: ${serialized[startIndex]}, supported: {${FEATURE_TAG_ARRAY.joinToString(", ")}}. Will default to Edge. Use a V1 supported tag or update the library to the latest version to fix this issue."
                    }
                    Feature.Edge(parseCubics(serialized, startIndex + 1, endIndex))
                }
            }
        }

        private fun parseCubics(serialized: String, startIndex: Int, endIndex: Int): List<Cubic> {
            // this is a very low level implementation to avoid allocations as the parsing of
            // serialized shapes can happen per frame. the functional equivalent would be
            // val points = serialized.serialized.substring(startIndex, endIndex)
            //                              .split(separator).map { it.toFloat() }
            // return points.windowed(8, step = 6).map { Cubic(it.toFloatArray()) }

            val windowSize = 8
            val windowStep = 6

            var pointStart = startIndex
            var pointEnd = startIndex
            var pointCount = 0
            var points = FloatArray(windowSize)

            // helper values saved in top level to avoid new allocations
            var nextStartX: Float
            var nextStartY: Float

            return buildList {
                while (pointEnd < endIndex) {
                    // the number is still going, move to next character
                    if (serialized[pointEnd] != SEPARATOR) {
                        pointEnd++
                        continue
                    }

                    // a number ended, add it to the points array and increase count
                    points[pointCount++] = serialized.substring(pointStart, pointEnd).toFloat()
                    pointStart = pointEnd + 1

                    // if we closed our window, parse it to cubic and reset values for the
                    // next window
                    if (pointCount == windowSize) {
                        add(Cubic(points))

                        // end of this cubic is start of next. we reset the points array
                        // because cubics save references to them and we would otherwise
                        // need to deep copy the array to avoid the same reference across all cubics
                        nextStartX = points[windowSize - 2]
                        nextStartY = points[windowSize - 1]
                        points = FloatArray(windowSize)
                        points[0] = nextStartX
                        points[1] = nextStartY
                        pointCount -= windowStep
                    }

                    pointEnd++
                }

                require(pointCount + 1 == windowSize) {
                    val neededPoints =
                        try {
                            serialized.substring(pointStart, pointEnd).toFloat()
                            windowSize - (pointCount + 1)
                        } catch (exception: NumberFormatException) {
                            windowSize - pointCount
                        }

                    "Received a feature with an insufficient amount of numbers for substring '${serialized.substring(startIndex-1, endIndex)}'. Wanted to create ${this.size+1} continuous cubic bezier curves for this feature, but the last one is missing $neededPoints more numbers separated by '$SEPARATOR'."
                }

                // add last point and last cubic
                points[windowSize - 1] = serialized.substring(pointStart, pointEnd).toFloat()
                add(Cubic(points))
            }
        }

        private fun String.removeTrailingZeroes(): String =
            this.trimEnd { it == '0' }.trimEnd { it == '.' }

        private const val SEPARATOR = ','
        private const val CONVEX_CORNER_CHAR = 'x'
        private const val CONCAVE_CORNER_CHAR = 'o'
        private const val EDGE_CHAR = 'n'
        private val FEATURE_TAG_ARRAY =
            charArrayOf(EDGE_CHAR, CONVEX_CORNER_CHAR, CONCAVE_CORNER_CHAR)
        private const val LOG_TAG = "FeatureSerializer"
    }
}
